Skip to content

Fix stale-token 403s: check JWT expiry and refresh proactively - #13

Merged
wparad merged 5 commits into
mainfrom
claude/403-errors-investigation-hi5rf3
Aug 25, 2026
Merged

Fix stale-token 403s: check JWT expiry and refresh proactively#13
wparad merged 5 commits into
mainfrom
claude/403-errors-investigation-hi5rf3

Conversation

@wparad

@wparad wparad commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Investigated widespread 403s on /api/accounts/{id}/threads, /resources, and the Realtime WebSocket. Root cause: AuthressLoginClient.getToken() validated the iss claim but never checked exp, so an already-expired-but-well-formed JWT was returned as valid indefinitely. That silently broke userIsLoggedIn()'s only refresh trigger (getToken() == null), so PATCH /session stopped firing once a session existed — exactly the failure NavGraph's per-route userIsLoggedIn() call was meant to prevent. Both the REST client (AuthInterceptor) and the WebSocket client (RealtimeClient) pull the token through the same waitForToken() path, so they failed identically and simultaneously.

  • getToken() now rejects a token whose exp (already shortened by JwtManager's 10s clock-skew buffer) has passed.
  • waitForToken() — the single choke point both AuthInterceptor (REST) and RealtimeClient (WebSocket) go through to attach auth — now actively calls userIsLoggedIn() (which triggers PATCH /session) whenever it has no valid cached token, instead of passively waiting on a sessionEstablished flow that nothing was flipping. userIsLoggedIn() no-ops (no network call) whenever a valid token is already cached, so this doesn't add overhead to the common case.
  • This covers every HTTP/WS call in the app without needing to sprinkle userIsLoggedIn() calls across individual call sites: apiService (all Email API calls, via AuthInterceptor) and RealtimeClient are the only two network egress paths (verified — AppContainer.kt is the only place building an OkHttpClient/Retrofit instance), and both already route through waitForToken().
  • Updated stale doc comments in AuthInterceptor and NavGraph that no longer matched the new behavior.

Test plan

  • No Android SDK available in this sandbox to run a full build/instrumented test — please verify :app:assembleDebug and :app:testDebugUnitTest pass in CI.
  • Manually verify: sign in, let the app sit past token expiry (or force-expire via clock skew), confirm the next API/WebSocket call triggers a PATCH /session refresh instead of a 403.
  • Confirm sync/reconnect loops (SyncForegroundService, RealtimeClient's backoff) recover cleanly after an expired token instead of looping on 403s.

Generated by Claude Code

claude added 5 commits August 25, 2026 14:04
…ry call

getToken() validated the iss claim but never checked exp, so an expired
token was returned as valid forever. That broke userIsLoggedIn()'s only
trigger condition (getToken() == null), making the PATCH /session refresh
a no-op once a session existed — exactly the failure NavGraph's per-route
call was meant to prevent.

waitForToken(), the single choke point both AuthInterceptor (REST) and
RealtimeClient (WebSocket) go through to attach auth, now actively calls
userIsLoggedIn() whenever it has no valid cached token, instead of passing
waiting on a session-established flow that nothing was flipping. This
covers every HTTP/WS call without needing to sprinkle userIsLoggedIn()
calls across call sites, since apiService and RealtimeClient are the only
two network egress paths in the app.
A local-only getToken() != null check can't recover from an expired
token the way userIsLoggedIn() can (it triggers PATCH /session), so
gating auth-required navigation on the sync check risked landing on
LOGIN or APP based on stale state. Both RootNavGraph call sites now
call the suspend userIsLoggedIn() instead.
Covers every success and failure mode of AuthressLoginClient (token
expiry/refresh, the full authenticate()/completeAuthenticationRequest()
PKCE + deep-link flow including abandoned/mismatched/duplicate redirect
edge cases, logout, linkIdentity, profile/devices), JwtManager's decode
and anti-abuse hash, AuthInterceptor's header attachment, and
RealtimeClient's handshake/reconnect/account-switch behavior — the
exact surface behind the 403 bug fixed on this branch.

Tests run against a real MockWebServer (redirected via a test-only
interceptor, since the client's host is a fixed BuildConfig value) with
AuthressCookieJar/AuthStorageManager faked via mockk rather than their
real EncryptedSharedPreferences-backed implementations. Robolectric
provides the android.util.Base64 (JWT decode) and Context (CustomTabsIntent
launch) support the client needs; it isn't required for AuthInterceptor
or RealtimeClient, which stay plain JUnit.

AuthStorageManager is now injected into AuthressLoginClient the same way
cookieJar already is, so tests can substitute a fake instead of touching
encrypted storage.
…ckk imports

Both are members of MockKAnswerScope, resolved via the answers{} block's
implicit receiver — importing them as free functions doesn't compile.
Switched the pending-auth-request stub to any()+firstArg() (any() already
matches null), and dropped the bogus secondArg import since the bare call
inside answers{} was already correct.
- Switch runTest to runBlocking across the AuthressLoginClient/JwtManager
  test suites: runTest's virtual-time scheduler auto-advances past the
  real Dispatchers.IO network call inside AuthressLoginClient.execute(),
  which made withTimeoutOrNull in waitForToken() fire its timeout before
  the real MockWebServer response arrived (seen failing:
  "waitForToken refreshes an expired token and returns the new one").
  These tests do real I/O and never use virtual-time control, so
  runBlocking is the correct tool.
- RealtimeClientTest: MockWebServer.shutdown() can throw IOException in
  tearDown when a WebSocket's close handshake (from client.stop()) hasn't
  finished yet. Harmless — wrap in runCatching.
@wparad
wparad merged commit 410b72d into main Aug 25, 2026
2 checks passed
@wparad
wparad deleted the claude/403-errors-investigation-hi5rf3 branch August 25, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants